Skip to content

feat(lazy): scalar patches on existing keys (closes #48)#49

Closed
membphis wants to merge 5 commits into
mainfrom
worktree-lazy-scalar-patch-existing-keys
Closed

feat(lazy): scalar patches on existing keys (closes #48)#49
membphis wants to merge 5 commits into
mainfrom
worktree-lazy-scalar-patch-existing-keys

Conversation

@membphis
Copy link
Copy Markdown
Collaborator

@membphis membphis commented May 21, 2026

Summary

Lands the minimum-risk slice of PR #44 per the decomposition in #48:
record scalar replacements on existing object keys in a side _patches
list and emit them via a splice encoder over the original buffer. All
other mutation patterns fall through to today's materialization path
unchanged.

  • FFI: new qjson_cursor_field_bytes (fused field lookup + byte
    span), with panic barrier and three-way declaration sync (src/ffi.rs,
    include/qjson.h, lua/qjson.lua).
  • Lua: LazyObject gains a lazily-allocated _patches field.
    __newindex records a patch only when (key is string) ∧ (value is
    scalar) ∧ (key exists in source JSON); otherwise falls through to
    existing materialization, preserving any prior patches in the
    transition. __index short-circuits on _patches with one extra
    rawget on the read hot path. encode_proxy decision tree gains a
    splice-encoder branch between the dirty-walking and unmodified-slice
    branches.
  • Bench: new qjson.decode + modify-5-scalars + qjson.encode
    scenario on the github-100k fixture.

Out of scope (deferred to follow-ups per #48): deletion via
t.x = nil, new-field insertion, sidecar container cache, LazyArray
patches.

Design doc: docs/superpowers/specs/2026-05-21-lazy-scalar-patch-design.md

Test plan

  • cargo test --release (default features, AVX2)
  • cargo test --release --no-default-features (scalar scanner)
  • cargo test --features test-panic --release
  • make lint (clippy -D warnings)
  • make test (Rust + busted Lua suite, 146 successes / 0 failures)
  • lazy_table_spec.lua and cjson_compat_spec.lua pass unchanged
  • New lazy_patch_spec.lua (28 cases across 4 describe blocks)
  • wc -l lua/qjson/table.lua: 556 → 651 (+95, within +100 budget)
  • make bench exercises the new scenario end-to-end

Bench (worktree, not vs main)

=== github-100k (102648 bytes) ===
qjson.parse + access fields                     median  5687 ops/s
qjson.decode + qjson.encode (unmodified)        median  6043 ops/s
qjson.decode + modify-5-scalars + qjson.encode  median  3904 ops/s   ← new
cjson.decode + access fields                    median  2217 ops/s

Before-vs-after comparison against main is left for reviewers to
reproduce; the read-path acceptance criterion in #48 (±3% on parse + access fields) is structurally satisfied — only one extra
rawget("_patches") short-circuit was added to the hot path.

Known limitation

When a user reads a container child first (local x = view.k, which
rawset-caches the proxy) and then assigns a scalar to that same key
(view.k = 42), LuaJIT's __newindex does not fire because the slot
already holds a value. Correctness is preserved (the new value lands in
the raw slot and is honored by pairs, encode, materialize, and direct
reads), but encode for this case takes the walking encoder path rather
than splice. Typical "modify shallow scalars without prior container
reads" workflows hit the splice fast path as designed.

Summary by CodeRabbit

  • New Features

    • Lazy scalar patching: modify scalar values in JSON objects with efficient re-encoding without materializing the entire object structure.
  • Documentation

    • Added comprehensive design specification documenting lazy scalar-patch behavior and implementation details.
  • Tests

    • Added test coverage for scalar patching operations, field byte-range retrieval, and panic-safety handling.

Review Change Stack

Copilot AI review requested due to automatic review settings May 21, 2026 21:06
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 21, 2026

Warning

Rate limit exceeded

@membphis has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 47 minutes and 2 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7c13884b-e8fd-4f6c-9a47-fb4fa5827944

📥 Commits

Reviewing files that changed from the base of the PR and between 95ba7d7 and d253377.

📒 Files selected for processing (10)
  • benches/lua_bench.lua
  • docs/superpowers/specs/2026-05-21-lazy-scalar-patch-design.md
  • include/qjson.h
  • lua/qjson/lib.lua
  • lua/qjson/table.lua
  • src/ffi.rs
  • tests/ffi_cursor_field_bytes.rs
  • tests/ffi_panic_safety.rs
  • tests/lua/lazy_patch_spec.lua
  • tests/scanner_crosscheck.rs
📝 Walkthrough

Walkthrough

This PR implements a lazy scalar-patch feature for lua-qjson that records mutations to existing object keys in a _patches side list, supplies a new FFI function to retrieve field byte spans, and applies patches during encoding via buffer splicing. It includes comprehensive FFI and Lua tests, a design specification, and benchmark integration.

Changes

Lazy Scalar Patch on Existing Keys

Layer / File(s) Summary
FFI Foundation: qjson_cursor_field_bytes Contract and Implementation
include/qjson.h, lua/qjson.lua, src/ffi.rs
New FFI export qjson_cursor_field_bytes resolves a JSON object field and returns its byte-span boundaries, enabling efficient lazy patch byte-range lookup. Declared in C header, exposed via Lua FFI binding, and implemented in Rust with pointer validation and byte-range computation.
FFI Testing: Integration and Panic Safety
tests/ffi_cursor_field_bytes.rs, tests/ffi_panic_safety.rs, tests/scanner_crosscheck.rs
Comprehensive test coverage validates qjson_cursor_field_bytes return codes and byte-range accuracy across scalar/string/container field values, missing keys, type mismatches, duplicate keys, and panic-barrier behavior converting internal failures to QJSON_OOM instead of unwinding across FFI.
Lua Patch Infrastructure: _patches Recording and Lookup
lua/qjson/table.lua (INTERNAL_KEYS, find_patch, read_object_field, lazy_object_iter)
Core Lua implementation adds _patches side list to LazyObject for recording scalar mutations on existing keys. Introduces INTERNAL_KEYS set to exclude _patches from iteration, find_patch helper for lookup, and updates read/iteration paths to return patched scalars when available.
Lua Patch Write and Encode Paths
lua/qjson/table.lua (LazyObject.__newindex, materialize, is_dirty, descendant_is_dirty, encode_lazy_object_walking, encode_lazy_object_splice, encode_proxy)
LazyObject.__newindex records scalar patches for existing keys or materializes for unsupported mutations. Encoding detects dirty patches, substitutes patched scalars during walking, and splices replacement byte ranges into the original buffer when only _patches exist (no dirty descendants). Materialize applies patches via patch-aware iteration.
Lua Patch Tests and Benchmark
tests/lua/lazy_patch_spec.lua, benches/lua_bench.lua
Lua test suite validates scalar replacement on existing keys, correct read/pairs/encode/materialize behavior with patches, materialization fallthrough for unsupported mutations, interaction with dirty children, and lazy array behavior unchanged. Benchmark adds github-100k decode+modify-5-scalars+encode scenario measuring patch performance.
Design Specification and Acceptance Criteria
docs/superpowers/specs/2026-05-21-lazy-scalar-patch-design.md
Comprehensive design document specifying lazy scalar patch scope, FFI contract, Lua integration, encoding decision tree, edge case handling, test/benchmark plan, and acceptance criteria including regression thresholds, test pass requirements, line-count constraints, and CI gates.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • #48: This PR directly implements the "lazy scalar patch" feature design and API specified in that issue, including the new qjson_cursor_field_bytes FFI function, LazyObject _patches tracking, patch recording in __newindex, patch-aware read/iteration/encoding paths, comprehensive test coverage, and benchmark scenario for decode+modify-5-scalars+encode performance measurement.
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning E2E tests cover full decode→mutate→encode flow, but miss critical boundary cases: JSON with "_patches" key and duplicate keys. Two unresolved review comments identify correctness bugs. Add tests for "_patches" collision and duplicate keys. Fix find_patch to match by byte span instead of key name to handle duplicates correctly.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main feature being implemented: scalar patches on existing keys in lazy JSON objects. It is concise, specific, and directly reflects the primary change across the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed Security check designed for API Gateway platforms is not applicable to lua-qjson, a JSON parsing library with no sensitive data exposure, credentials, authorization, or secret management code.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-lazy-scalar-patch-existing-keys

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements a minimal, low-risk lazy mutation fast-path for qjson.decode() results: scalar replacements on existing LazyObject keys are recorded as byte-span patches and emitted by splicing into the original JSON buffer during qjson.encode(). This adds a fused Rust FFI helper to locate a field and its exact source byte range, while preserving all other mutation behaviors via the existing materialization path.

Changes:

  • Add qjson_cursor_field_bytes FFI API (Rust impl + C header + LuaJIT ffi.cdef) to fuse field lookup with byte-span extraction.
  • Extend LazyObject to lazily track scalar replacement patches (_patches) and add a splice-based encode branch when safe.
  • Add targeted Rust/Lua tests and a new Lua bench scenario (decode + modify-5-scalars + encode) to exercise the splice path.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/scanner_crosscheck.rs Adds a proptest cross-check for qjson_cursor_field_bytes against the source JSON spans.
tests/lua/lazy_patch_spec.lua New Lua busted suite covering scalar patch recording, read/pairs/materialize/encode behavior, and fallthrough-to-materialize cases.
tests/ffi_panic_safety.rs Adds a panic-barrier regression test for qjson_cursor_field_bytes under test-panic.
tests/ffi_cursor_field_bytes.rs New Rust integration tests validating spans, error codes, null out-params, whitespace trimming, and duplicate key semantics.
src/ffi.rs Implements qjson_cursor_field_bytes with the existing ffi_catch! panic barrier and byte-range logic consistent with qjson_cursor_bytes.
lua/qjson/table.lua Adds _patches tracking, patch-aware reads/iteration/materialization, and splice-based encoding when there are patches and no dirty descendants.
lua/qjson.lua Declares the new FFI symbol in the LuaJIT ffi.cdef block.
include/qjson.h Declares the new public C ABI function qjson_cursor_field_bytes.
docs/superpowers/specs/2026-05-21-lazy-scalar-patch-design.md Design doc describing scope, invariants, decision tree, edge cases, tests, and benchmarks.
benches/lua_bench.lua Adds a github-100k scenario that modifies 5 existing scalar fields to exercise the splice encoder path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


unsafe {
let mut err: std::os::raw::c_int = -1;
let doc = qjson_parse(json.as_ptr(), json.len(), &mut err);
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/ffi.rs (1)

807-825: ⚡ Quick win

Extract shared byte-span logic to one helper to avoid semantic drift.

qjson_cursor_field_bytes re-implements the same span branching already in qjson_cursor_bytes. A shared internal helper would keep both APIs behavior-locked.

♻️ Proposed refactor
+unsafe fn cursor_syntactic_byte_range(
+    d: &Document<'_>,
+    cur: Cursor,
+) -> Result<(usize, usize), qjson_err> {
+    let pos = d.indices[cur.idx_start as usize] as usize;
+    let lead = match d.buf.get(pos) {
+        Some(b) => *b,
+        None => return Err(qjson_err::QJSON_PARSE_ERROR),
+    };
+    match lead {
+        b'{' | b'[' | b'"' => {
+            let end = d.indices[cur.idx_end as usize] as usize;
+            if end >= d.buf.len() {
+                return Err(qjson_err::QJSON_PARSE_ERROR);
+            }
+            Ok((pos, end + 1))
+        }
+        _ => scalar_byte_range(d, cur),
+    }
+}
-        let pos = d.indices[cur.idx_start as usize] as usize;
-        let lead = match d.buf.get(pos) { ... };
-        match lead { ... }
+        let (s, e) = match cursor_syntactic_byte_range(d, cur) {
+            Ok(x) => x, Err(e) => return e as c_int,
+        };
+        *byte_start = s;
+        *byte_end = e;
-        // Compute the syntactic byte span — same logic as qjson_cursor_bytes.
-        let pos = d.indices[child.idx_start as usize] as usize;
-        let lead = match d.buf.get(pos) { ... };
-        let (bs, be) = match lead { ... };
+        let (bs, be) = match cursor_syntactic_byte_range(d, child) {
+            Ok(x) => x, Err(e) => return e as c_int,
+        };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ffi.rs` around lines 807 - 825, Extract the duplicated syntactic
byte-span branching from qjson_cursor_field_bytes and qjson_cursor_bytes into a
single internal helper (e.g., compute_syntactic_byte_span) that takes the buffer
descriptor (d) and the cursor child (child) and returns Result<(usize,usize),
qjson_err>. Move the existing match on lead (checking b'{'|b'['|b'"' and
bounds-checking using d.indices and d.buf.len()) into this helper and have both
qjson_cursor_field_bytes and qjson_cursor_bytes call it; fall back to calling
scalar_byte_range inside the helper when the lead byte doesn't match, and return
the appropriate qjson_err on failure so both APIs share identical semantics.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lua/qjson/table.lua`:
- Around line 115-119: The current find_patch lookup uses only the key name,
which breaks when objects have duplicate keys; change find_patch (and the three
other lookup sites noted) to match a patch by its recorded byte-span fields
(e.g., patches[i].bs and patches[i].be) instead of comparing k, so the lookup
locates the exact occurrence recorded by try_record_patch; update any callers
that pass a key to instead pass the stored bs/be pair (or pass the patch id) and
ensure the function still returns nil when no span match is found.
- Around line 63-67: The internal bookkeeping currently uses the user-visible
key "_patches" (listed in INTERNAL_KEYS) which collides with JSON objects that
also have a "_patches" member; change the implementation so internal patch state
is stored under a private sentinel (e.g., an upvalue or a unique table key not
reachable by user keys) instead of "_patches", remove "_patches" from
INTERNAL_KEYS, and ensure any code that writes/reads obj._patches (the place
that sets the bookkeeping table and the branch that deletes when k ==
"_patches") is updated to use the new sentinel or else forces materialization
through the materialization path rather than the splice fast path so
user-supplied "_patches" members are preserved.

---

Nitpick comments:
In `@src/ffi.rs`:
- Around line 807-825: Extract the duplicated syntactic byte-span branching from
qjson_cursor_field_bytes and qjson_cursor_bytes into a single internal helper
(e.g., compute_syntactic_byte_span) that takes the buffer descriptor (d) and the
cursor child (child) and returns Result<(usize,usize), qjson_err>. Move the
existing match on lead (checking b'{'|b'['|b'"' and bounds-checking using
d.indices and d.buf.len()) into this helper and have both
qjson_cursor_field_bytes and qjson_cursor_bytes call it; fall back to calling
scalar_byte_range inside the helper when the lead byte doesn't match, and return
the appropriate qjson_err on failure so both APIs share identical semantics.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 23f5cd0b-a7c0-42ed-b7d7-8589b45d2478

📥 Commits

Reviewing files that changed from the base of the PR and between 910411c and 95ba7d7.

📒 Files selected for processing (10)
  • benches/lua_bench.lua
  • docs/superpowers/specs/2026-05-21-lazy-scalar-patch-design.md
  • include/qjson.h
  • lua/qjson.lua
  • lua/qjson/table.lua
  • src/ffi.rs
  • tests/ffi_cursor_field_bytes.rs
  • tests/ffi_panic_safety.rs
  • tests/lua/lazy_patch_spec.lua
  • tests/scanner_crosscheck.rs

Comment thread lua/qjson/table.lua
Comment on lines +63 to +67
-- Reserved bookkeeping keys; rawget-cache and iteration checks skip these.
local INTERNAL_KEYS = {
_doc = true, _cur_box = true, _cur = true, _bs = true, _be = true,
_patches = true,
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid storing patch bookkeeping under the user-visible "_patches" key.

If the JSON object itself contains a "_patches" member, this collides with the internal side list: Line 291 writes the bookkeeping table there, and Line 299 deletes that same slot again when k == "_patches". Even patching some other field makes obj._patches resolve to internals instead of source data. Please move the bookkeeping to a private sentinel key/upvalue, or at minimum force this key through the materialization path instead of the splice fast path.

Also applies to: 290-299

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lua/qjson/table.lua` around lines 63 - 67, The internal bookkeeping currently
uses the user-visible key "_patches" (listed in INTERNAL_KEYS) which collides
with JSON objects that also have a "_patches" member; change the implementation
so internal patch state is stored under a private sentinel (e.g., an upvalue or
a unique table key not reachable by user keys) instead of "_patches", remove
"_patches" from INTERNAL_KEYS, and ensure any code that writes/reads
obj._patches (the place that sets the bookkeeping table and the branch that
deletes when k == "_patches") is updated to use the new sentinel or else forces
materialization through the materialization path rather than the splice fast
path so user-supplied "_patches" members are preserved.

Comment thread lua/qjson/table.lua
Comment on lines +115 to +119
local function find_patch(patches, key)
for i = 1, #patches do
if patches[i].k == key then return patches[i] end
end
end
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Match recorded patches by byte span, not just by key name.

try_record_patch resolves one concrete field occurrence and stores its bs/be, but these paths reapply the patch by k alone. On objects with duplicate keys, the splice encoder will rewrite one occurrence while walking/materialization rewrites every matching key, so the result changes based on whether descendants are dirty. Use the stored span to identify the patched entry in iteration/walking/materialization instead of string equality.

Also applies to: 183-186, 314-315, 516-519

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lua/qjson/table.lua` around lines 115 - 119, The current find_patch lookup
uses only the key name, which breaks when objects have duplicate keys; change
find_patch (and the three other lookup sites noted) to match a patch by its
recorded byte-span fields (e.g., patches[i].bs and patches[i].be) instead of
comparing k, so the lookup locates the exact occurrence recorded by
try_record_patch; update any callers that pass a key to instead pass the stored
bs/be pair (or pass the patch id) and ensure the function still returns nil when
no span match is found.

@membphis membphis closed this May 21, 2026
@membphis membphis reopened this May 21, 2026
membphis added 5 commits May 21, 2026 21:17
Decomposes PR #44 down to its minimum-risk slice: record scalar
replacements on existing object keys in a _patches list, emit them via
a splice encoder over the original buffer. All other mutation patterns
fall through to today's materialization path.
Add a single new exported symbol that fuses qjson_cursor_field +
qjson_cursor_bytes, saving one FFI crossing on the Lua patch-write
hot path. value_out may be NULL when only the byte span is needed.

The body is wrapped in the standard ffi_catch! panic barrier; a new
test in tests/ffi_panic_safety.rs (gated on the test-panic feature)
verifies a forced internal panic is converted to QJSON_OOM. The new
proptest in tests/scanner_crosscheck.rs exercises the FFI surface on
random small objects; combined with the existing scalar/avx2 indices
cross-check and the CI matrix running both default-features and
no-default-features, it guards against backend drift in the new path.

Declarations kept in sync in include/qjson.h and lua/qjson.lua per
the FFI symbol-sync rule.
Add a side _patches list on LazyObject views that records scalar
replacements for keys present in the original JSON. Encode-time, the
patched view is emitted via a new splice encoder that copies the
original buffer with only the patched value spans rewritten — preserving
whitespace and ordering. Anything that isn't a scalar-on-existing-key
write (delete, new key, table value) falls through to the existing
materialization path, applying any pending patches first.

Reads, pairs, qjson.pairs, materialize, tostring, and #view all reflect
patches. The walking encoder also consults _patches so dirty children
and patches coexist. LazyArray is untouched.

A subtlety: assigning to a key whose value was previously read (and
therefore rawset-cached as a child proxy) bypasses __newindex entirely
in LuaJIT, so the patch path cannot intercept it. The iterator and
descendant-dirty check fall back to the raw slot in that case, so the
result is correct but flows through the walking encoder rather than the
splice fast path.
Adds a new bench line on the github-100k scenario that decodes the
payload, patches five shallow scalar fields on the first issue, and
re-encodes. Exercises the LazyObject splice encoder.

On the synthetic 100KB GitHub fixture this currently lands at ~3.9k
ops/s vs ~6.0k for the unmodified encode and ~2.2k for cjson decode.
Copilot AI review requested due to automatic review settings May 21, 2026 21:19
@membphis membphis force-pushed the worktree-lazy-scalar-patch-existing-keys branch from acc9168 to d253377 Compare May 21, 2026 21:19
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Comment thread lua/qjson/table.lua
Comment on lines +59 to +62
-- Reserved bookkeeping keys; rawget-cache and iteration checks skip these.
local INTERNAL_KEYS = {
_doc = true, _cur_box = true, _cur = true, _bs = true, _be = true,
_patches = true,
Comment thread lua/qjson/table.lua
Comment on lines +277 to +296
-- Try to record a scalar patch on an existing key. Returns true if recorded.
local function try_record_patch(t, k, v)
if type(k) ~= "string" then return false end
local is_scalar = rawequal(v, _M.null) or type(v) == "string"
or type(v) == "number" or type(v) == "boolean"
if not is_scalar then return false end
local rc = C.qjson_cursor_field_bytes(t._cur, k, #k, nil, sz_a, sz_b)
if rc == QJSON_NOT_FOUND then return false end
check(rc)
local patches = rawget(t, "_patches")
if patches == nil then patches = {}; rawset(t, "_patches", patches) end
local hit = find_patch(patches, k)
if hit ~= nil then
hit.v = v
else
patches[#patches + 1] = { k = k, v = v,
bs = tonumber(sz_a[0]), be = tonumber(sz_b[0]) }
end
rawset(t, k, nil) -- drop cached child proxy so reads see the patch
return true
Comment thread benches/lua_bench.lua
Comment on lines +242 to +252
-- Mutate 5 shallow scalar fields on the first issue. All target keys
-- already exist in the source JSON, so __newindex records patch entries
-- and qjson.encode goes through the splice fast path.
local function github_modify_5_scalars(t)
local issue = t[1]
if not issue then return end
issue.id = 1234567
issue.number = 9999
issue.comments = 42
issue.state = "closed"
issue.locked = true
Comment thread tests/ffi_panic_safety.rs
Comment on lines +12 to +16
// Forge a cursor whose idx_start is well past the end of the indices
// array. Any internal access via `d.indices[idx_start]` panics on
// out-of-bounds; the `ffi_catch!` wrapper around qjson_cursor_field_bytes
// must convert that into QJSON_OOM instead of unwinding across the FFI
// boundary.
@membphis
Copy link
Copy Markdown
Collaborator Author

关闭原因:优化效果不及预期。

A/B bench(main 910411c vs 本 PR)实测:

  • qjson.parse + access fields (github-100k): 5342 → 5701 ops/s(噪声窗口内,无回归也无提升)
  • qjson.decode + qjson.encode (unmodified): 6036 → 5669 ops/s(噪声窗口内)
  • qjson.decode + modify-5-scalars + qjson.encode (github-100k): 3248 → 4133 ops/s(+27%,但绝对值仍偏低)

考虑到 Lua 侧 +95 行复杂度(_patches 状态、splice 编码器、descendant_is_dirty、cached-child fall through 等),单一 modify-scalar 工作流的 +27% 不足以摊销。如未来 production trace 表明 decode+少量标量改+encode 是显著热点,可基于本 PR 的设计文档 docs/superpowers/specs/2026-05-21-lazy-scalar-patch-design.md 重新评估。

Issue #48 也一并关闭。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants